#!/usr/bin/env python

# This product is under the MIT license.
#
# Copyright (c) 2010,
#
# Ben Caldwell      http://twitter.com/lankzy
# Wei Zhuo          http://twitter.com/xwz
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
#
# rev: $Id: plz 56 2010-05-13 07:32:49Z weizhuo $

import os, sys, re, errno

def install_action(packages, options={}):
    """
    Install a list of packages.
    Usage: plz install PACKAGES...

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque id consectetur
    urna. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere 
    cubilia Curae; Morbi adipiscing congue massa a imperdiet. Integer et nulla 
    posuere urna lobortis rhoncus id convallis lacus.

    Valid options:
        -n [--dryrun]           : do a dry run install
        -q [--quiet]            : print nothing, or only summary information
        -b [--branch] ARG       : use packages from a specific branch
        --force                 : force operation to run
    """
    print packages
    print range_expansions(options.host)

i_action = install_action

def help_action(action=None, options={}):
    """
    Display help information for an action.
    Usage: plz help ACTION
    actions: %s
    """
    doc = help_doc(first(action))
    if len(doc) == 0:
        actions = sorted(a for a,f in available_actions() if len(a) > 2)
        doc = help_doc('help') % ", ".join(actions)
    else:
        doc += "\n\n" + cleandoc(global_options.__doc__)
    print doc

def start_action(packages, options={}):
    """
    Start a list of packages.
    Usage: plz start PACKGES...
    """

def stop_action(packages, options={}):
    """
    Stop a list of packages.
    Usage: plz stop PACKAGES...
    """
def restart_action(packages, options={}):
    """
    Restart a list of packages.
    Usage: plz restart PACKAGES...
    """
def set_action(args, options={}):
    """
    Set package option values.
    Usage: plz set package.variable=value ...
    """
    print args

# --- package creation ---
def create_action(config_files, options={}):
    """
    Create new packages.
    Usage: plz create mypackage.conf ...

    Valid options:
        -t [--test]             : create a test package, default option
        -r [--release]          : create a production release package
        -l [--link]             : create a link package
        -c [--clean]            : clean up package working directory on completion
    """
    for file in config_files:
        build_package(file, options)

def build_package(config_file, options):
    """
    A series of steps in building new package tar.bz2
    Most of configuration errors are tracked here.
    """
    src_dir = dirname(config_file)
    # set the CWD of the shell to the directory of the config file
    sh = curry(shell, cwd=src_dir) 
    try:
        config = parse_config(open(config_file).read(), shell=sh, options=options)
        config_errors = validate_package_config(src_dir, config)
    except Exception, syn:
        # format syntax error message in parsing config file
        import traceback
        trace = []
        # truncate stack trace to start at the config file
        for line in traceback.format_exc(syn).splitlines():
            #print line # debug
            if 'File "<string>"' in line or 'File "<syntax-tree>"' in line:
                line = line.replace("<string>", config_file).strip()
                line = line.replace("<syntax-tree>", config_file)
                trace.append(line)
            elif len(trace)>0:
                trace.append("    "+line)
        if len(trace)==0:
            trace.append(str(syn))
        config_errors = ["\n".join(trace)]
    if len(config_errors) > 0:
        print "The package config '%s' has the following problems:" % config_file
        print "\n".join("  - %s" % e for e in config_errors)
    else:
        # everything looks ok, start building
        prepare_package(config, config_file, options)

def validate_package_config(src_dir, config):
    """
    Verify package contains minimal wellformed keywords such as NAME, VERSION, etc.
    """
    errors = []
    required = set(['NAME', 'VERSION', 'DESCRIPTION', 'AUTHOR'])
    found = required & set(config.keys())
    if len(found) < len(required):
        errors.append("Keywords %s are required." % ", ".join(required - found))
    if re.search('\s', config.get('NAME','')):
        errors.append('Package NAME "%s" may not contain any white spaces.' % config['NAME'])
    if not re.search('^\d+(\.\d{1,4})*$', config.get('VERSION', '')):
        errors.append('Package VERSION "%s" should be like "1.1234.9".' % config['VERSION'])

    # check for missing source files
    for file in config.get("FILES"):
        file["src_file"] = package_src_file(src_dir, file.get("src"))
        if not os.path.isfile(file["src_file"]):
            errors.append('Unable to find file "%s".' % file["src_file"])

    # find glob files, ensure at least 1 file
    for pattern in config.get("GLOBS"): 
        find = prepare_glob_files(src_dir, pattern)
        if len(pattern["files"])==0:
            errors.append('Unable to find files "%s".' % find)

    # execute find command, ensure at least 1 file
    for findcmd in config.get("FINDS"):
        prepare_find_cmd_files(src_dir, findcmd)
        if len(findcmd["files"])==0:
            errors.append('Unable to find files using command "find %s".' % findcmd["cmd"])
    return errors

def package_src_file(src_dir, filename):
    """
    If filename starts with "/" then assume absolute path, otherwise relative to src_dir.
    """
    if filename[0]=="/":
        return os.path.realpath(filename)
    else:
        return os.path.realpath("%(src_dir)s/%(filename)s" % locals())

def prepare_glob_files(src_dir, pattern):
    """
    Find all the source files and corresponding destination file 
    for a given glob pattern.
    """
    import glob
    find = package_src_file(src_dir, pattern.get("src"))
    file_template = pattern.copy()
    pattern["files"] = []
    for filename in glob.glob(find):
        info = get_src_file_info(file_template, src_dir, filename)
        if info != None:
            pattern["files"].append(info)
    return find

def get_src_file_info(template, src_dir, filename):
    if os.path.isfile(filename):
        info = template.copy()
        info["src_file"] = filename
        info["src"] = filename.replace(src_dir+"/", "")
        if info["dest"][-1]=="/":
            info["dest"] = "%(dest)s%(src)s" % info
        else:
            info["dest"] = "%(dest)s/%(src)s" % info
        return info
    return None

def prepare_find_cmd_files(src_dir, find, exe="/usr/bin/find"):
    sh = curry(shell, cwd=src_dir) 
    files = sh("%s %s" % (exe, find["args"]))
    file_template = find.copy()    
    find["files"] = []
    for found in files.split("\n"):
        filename = package_src_file(src_dir, found)
        info = get_src_file_info(file_template, src_dir, filename)
        if info != None:
            find["files"].append(info)

def prepare_package(config, config_file, options):
    """
    Creates working directory, copy files, and build a tar package file.
    """
    working_dir = package_working_dir(config)
    rmdir(working_dir) 

    for file in package_file_list(config):
        prepare_package_file(working_dir, file, config, options)

    create_package_meta(working_dir, config_file, config)    
    create_tar_package(config, config_file, options)
    if options.get("clean"):
        rmdir(package_working_dir(config))

def package_file_list(config):
    """
    Return a list of files (with source, destination, and permission) for the
    package, that is, files in FILES and GLOBS.
    """
    files = []
    files.extend(config.get("FILES"))
    files.extend(config.get("MKDIRS"))
    for pattern in config.get("GLOBS"):
        files.extend(pattern["files"])
    for find in config.get("FINDS"):
        files.extend(find["files"])
    return files

def prepare_package_file(working_dir, file, config, options):
    """
    Copies the source file to the destination file in the working directory.
    Creates a symlink file if the option is to create "--link"
    """
    import shutil
    file["working_file"] = "%s/%s" % (working_dir, file["dest"])

    if file.get("src_file"):
        mkdir(os.path.dirname(file["working_file"]))
        if not os.path.isfile(file["working_file"]):
            if options.get("link"):
                os.symlink(file["src_file"], file["working_file"])
            else:
                shutil.copy(file["src_file"], file["working_file"])
    elif file.get("dir"):
        mkdir(file["working_file"])
    file["size"] = os.path.getsize(file["working_file"])
    # set file permissions on non-symlinked files
    perm = file.get("perm") or config.get("PERM")
    if config.get("link") == None and perm:
        os.chmod(file["working_file"], perm)

def create_package_meta(working_dir, config_file, config):
    """
    Copy the package.conf fils and other meta data to a new directory named "plz.dist".
    Files in "plz.dist" should be the original files.
    A file named "+PACKAGE" is create to contain the package name, version,
    description, and destination file list.
    """
    import shutil, pprint
    distdir = "%s/plz.dist" % working_dir
    mkdir(distdir)
    shutil.copy(config_file, "%s/package.conf" % distdir)
    basics = ('NAME', 'VERSION', 'DESCRIPTION', 'CHANGELOG', 'AUTHOR', 'WEBSITE')
    info = dict()
    for keyword in basics:
        info[keyword] = config.get(keyword)
    file_info = ('dest', 'group', 'owner', 'perm', 'size')
    info["FILES"] = []
    info["PAYLOAD_SIZE"] = 0
    for file in package_file_list(config):
        info["FILES"].append(dict((k,file.get(k)) for k in file_info))
        info["PAYLOAD_SIZE"] += file.get("size") or 0
    package_info = pprint.pformat(info)
    f = open("%s/+PACKAGE" % working_dir, 'w')
    f.write(package_info)
    f.close()

def get_package_meta(package_file):
    """
    Tries to find the +PACKAGE meta data in the first 10 files of the tar bz2 file.
    The file object can be the 1st block of the tar file (900k in size).
    Uses EVAL to load the meta data string. Returns None on missing package meta file.
    """
    import tarfile
    if not hasattr(package_file, "read") and os.path.isfile(package_file):
        package_file = file(package_file)
    if hasattr(package_file, "read"):
        try:
            tar = tarfile.open(fileobj=package_file, mode="r:*")
            for i in range(1,10):
                tarinfo = tar.next()
                if tarinfo and tarinfo.name[-9:] == '/+PACKAGE':
                    info = tar.extractfile(tarinfo).read()
                    return eval(info)
        except tarfile.ReadError:
            pass
    return None

def create_tar_package(config, config_file, options):
    """
    Create a tar file with bzip2 compression, and add all the files and directories 
    in the working directory. File pemissions and ownership can be configured globally
    as OWNER, GROUP or per file basis as "owner" and "group" as defined in the functions
    in package_config_funcs().
    """
    import tarfile
    
    package_output_dir = "%(NAME)s-%(VERSION)s" % config

    # build a list of destination file ownerships
    file_owners = dict()
    for file in package_file_list(config):
        dest    = "%s/%s" % (package_output_dir, file["dest"])
        file_owners[dest] = dict(uname=file.get("owner"), gname=file.get("group"))
    default_owner = dict(uname=config.get("OWNER"), gname=config.get("GROUP"))
    
    def update_ownership(tarinfo):
        ownership = default_owner.copy()
        
        # override default ownership from file config
        if tarinfo.name in file_owners:
            file_owner = file_owners[tarinfo.name].items()
            file_owner = dict((k,v) for k,v in file_owner if v != None)
            ownership.update(file_owner)

        # update each file with new ownership info
        for k,v in ownership.items():
            if v != None:
                setattr(tarinfo,k,v)
    
    # update file ownership before adding to tar
    class PlzTarFile(tarfile.TarFile):
        def addfile(self, tarinfo, fileobj=None):
            update_ownership(tarinfo)
            tarfile.TarFile.addfile(self, tarinfo, fileobj)
        # allow us to change the compression level 1 to 9
        # each level equal to the i*100kb independent blocks
        @classmethod
        def bz2open(self, name, mode="r", fileobj=None, compresslevel=9, **kwargs):
            return tarfile.TarFile.bz2open(name, mode, fileobj, 9, **kwargs)

    # create tar file
    filename = package_tar_name(config, options)
    if os.path.isfile(filename):
        os.remove(filename)
    tar = PlzTarFile.open(filename, 'w:bz2')
    tar.add(package_working_dir(config),package_output_dir)
    tar.close()    

def package_working_dir(config):
    """
    Working directory for building package, based on 
    package name and platform identifier.
    """
    import platform
    return "%s-%s-%s" % (config.get("NAME"), config.get("VERSION"), platform.platform())

def package_tar_name(config, options):
    """
    Tar file name, based on release state. Adds timestamp for
    symlink and test packages.
    """
    import time
    if options.get("release"):
        version = config.get("VERSION")
    elif options.get("link"):
        version = "%sL%d" % (config.get("VERSION"), time.time())
    else:
        version = "%sT%d" % (config.get("VERSION"), time.time())
    return "%s-%s.tar.bz2" % (config.get("NAME"), version)

# --- package activation ---

def activate_action(args, options={}):
    """
    Activate packages.
    Usage: plz activate PACKAGES...
    """

enable_action = activate_action

def deactivate_action(args, options={}):
    """
    Deactivate packages.
    Usage: plz deactivate PACKAGES...
    """

disable_action = deactivate_action

def remove_action(packages, options={}):
    """
    Remove packages.
    Usage: plz remove PACKAGES...
    """

uninstall_action = rm_action = remove_action 

def dist_action(files, options={}):
    """
    Push package files to a repository.

    Valid options:
        -r [--repo] ARG...      : respository url
    """

def list_action(packages=[], options={}):
    """
    List installed packages.
    Usage: plz list [PACKAGES...]
    """
    for pack in packages:
        print get_package_meta(pack)

ls_action = list_action

def search_action(args, options={}):
    """
    Search for packages.
    Usage: plz search NAME...
    """

find_action = search_action

def dependents_action(packages, options={}):
    """
    List package dependencies.
    Usage: plz dependents PACKAGES...
    """

deps_action = dependents_action

# --- system support fuctions ---

def mkdir(path):
    """
    Create intermediate directories as required.
    """
    try:
        os.makedirs(path)
    except OSError, e:
        if e.errno == errno.EEXIST:
            pass
        else: raise

def rmdir(path):
    """
    Delete everything reachavle from the directory path, including itself.
    CAUTION: This can delete path = "/"
    """
    for root, dirs, files in os.walk(path, topdown=False):
        for name in files:
            os.remove(os.path.join(root, name))
        for name in dirs:
            os.rmdir(os.path.join(root, name))
    if os.path.isdir(path):
        os.rmdir(path)

# --- support functions ---

def default_help():
    info = """
    Usage: plz <command> [options] [args]
    Type 'plz help <command>' for help on a specific command.

    Available actions:
        %(actions)s
    
    plz is a simple package management system.
    For additional information, see http://getplz.com/
    rev: $Id: plz 56 2010-05-13 07:32:49Z weizhuo $
    """
    names = []
    for alias in [sorted(a, key=len, reverse=True) for a in aliases().values()]:
        if len(alias) > 1:
            names.append("%s (%s)" % (alias[0], ", ".join(alias[1:])))
        else:
            names.append(alias[0])
    actions = "\n    ".join(sorted(names))
    return cleandoc(info) % locals()

def aliases():
    """Returns action function names, including aliases"""
    actions = dict()
    for a,f in available_actions():
        if f not in actions:
            actions[f] = []
        actions[f].append(a)
    return actions

def help_doc(action):
    """
    Returns the documentation for an action, empty if unknown action.
    """
    return cleandoc(dict(available_actions()).pop(action, None).__doc__ or "")

def cleandoc(doc):
    return re.compile("^ {4}", re.M).sub("", doc).strip()

def available_actions():
    return [(a[:-7], f) for a,f in globals().items() 
                if type(f).__name__ == 'function' and a[-7:] == '_action']

def global_options(arg=None):
    """
    Global options:
        --root ARG              : the base file system path, default is /opt/plz
        --username ARG          : specify a username ARG
        --password ARG          : specify a password ARG
        -h [--host] ARG...      : remote hosts

    All option values can be set via environment variables as PLZ_ABC where ABC is
    the option name in uppercase. E.g. PLZ_USERNAME=wheel plz install ... 
    """
    default = {
        'root'  : '/opt/plz',
    }
    if arg in default:
        return default[arg]

def range_expansion(s):
    """
    Expand square brackets placeholders of the form [x-y] and [a,b,c] to form 
    a set of strings that replace the placeholers with range(x,y) 
    and 'a', 'b', 'c', respectively, and recursively. 

    For example, the string 's[1-3].example.com' becomes
    's1.example.com', 's2.example.com', and 's3.example.com'.

    The string 'www.[one, two, three].example.com' becomes
    'www.one.example.com', 'www.two.example.com', and 'www.three.example.com'.

    Range values [x-y] must be integers and can be nested with [a,b,c] expansions.
    For example, 'www.[s[1-3], p[1-3]].example.com' will produce:
        'www.s1.example.com',
        'www.s2.example.com',
        'www.s3.example.com',
        'www.p1.example.com',
        'www.p2.example.com',
        'www.p3.example.com'.

    Similar result for 'www.[s,p][1-3].example.com'.
    """
    lines = []
    def sub_range(m):
        for i in range(int(m.group(1)), int(m.group(2))+1):
            lines.append(m.string.replace(m.group(0),str(i),1))
    def sub_element(m):
        els = [w.strip() for w in m.group(1).split(',') if len(w.strip())>0]
        lines.extend(m.string.replace(m.group(0), e, 1) for e in els)
    re.sub('\[(\d+)-(\d+)\]', sub_range, s, 1)
    re.sub('\[([\w,.:\s]+)\]', sub_element, s, 1)
    result = set()
    for line in lines:
        if re.search('\[.+\]', line):
            result |= range_expansion(line)
        else:
            result.add(line)
    return result or set([s])

def range_expansions(iter):
    """Do range expansion over a list, returns a set of expansions."""
    results = set()
    for s in iter or []:
        results |= range_expansion(s)
    return results

def get_options(action):
    import optparse
    """
    Extract action options from action function doc. Returns OptionParser.
    
    Action can specify in the docstring a list of optional arguments
    that will be processed and passed as the options argument to the action function.
    Additional options defined in global_options() is also processed.

    The following formats are supported:

        -n [--name] ARG...      : short/long multiple value option
        -n [--name] ARG         : short/long value option
        -n [--name]             : short/long boolean option
        --host ARG...           : long multiple value option
        --name ARG              : long value option
        --name                  : long boolean option
    
    The ARG denotes that the option can take a value, otherwise boolean options.
    The option can take multiple values by defining ARG... (suffix with 3 full stops)
    Multiple value option must be specified in the command line as:

        -h host1 -h host2 -h host3 ...

    The following formats are NOT supported: 
    (short options MUST be an alternative to the long option)

        -n ARG...               : short multiple value option
        -n ARG                  : short value option
        -n                      : short boolean option
    """
    doc = help_doc(action)
    usage = re.findall("^(usage: .*)$", doc, re.M | re.I) or ["Usage: plz <command>"]
    parser = optparse.OptionParser(usage=usage[0], conflict_handler="resolve")
    def parse_options(doc, parser):
        pattern = "^\s*(-{1,2}(\w+))(?:\s+\[(--(\w+))\])?\s*([\w.]+)?\s*:"
        options = [filter(lambda x:len(x)>1, p) for p in re.findall(pattern, doc, re.M)]
        for opt in options:
            env = os.getenv("PLZ_%s" % opt[-2].upper()) or global_options(opt[-2])
            if opt[-1] == 'ARG':
                parser.add_option(*opt[:-2], **dict(dest=opt[-2], default=env))
            elif opt[-1] == 'ARG...':
                parser.add_option(*opt[:-2], **dict(dest=opt[-2], 
                            action='append', default=filter(None, (env or "").split())))
            else:
                parser.add_option(*opt[:-1], **dict(dest=opt[-1], action="store_true"))
    parse_options(doc, parser)
    parse_options(global_options.__doc__, parser)
    return parser

def min_args(func):
    import inspect
    """Returns minimum number of required arguments of the function."""
    spec = inspect.getargspec(func)
    return len(spec[0]) - len(spec[3]) 

def first(iterable):
    """Returns 1st element of an iterable."""
    for item in iterable or []:
       return item

def dispatch(argv):
    """
    Calls action functions with list of values and options dict.
    
    All action functions must accept 2 arguments, first a list
    of input argument values, and second an options dictionary.

    The action function's first argument may be optional, otherwise
    the user must provide at least one input argument value.
    """
    func = dict(available_actions()).pop(argv[1],None)
    parser = get_options(argv[1])
    if func:
        options, args = parser.parse_args(argv[2:])
        if len(args) >= min_args(func):
            func(args, vars(options))
        else:
            # finds the original action name, not alias
            action = first([a for a in aliases().pop(func) if len(a)>2])
            print help_doc(action)
            print "\nERROR: command '%s' requires an argument." % action
    else:
        parser.error("Unknown command '%s'" % argv[1])

def shell(cmdstr, input=None, **kw):
    """
    Execute system commands. e.g. shell('ls | grep opt')
    """
    import shlex, subprocess
    for cmd in partition(shlex.split(cmdstr), '|'):
        p = subprocess.Popen(cmd, stdin=input, stdout=subprocess.PIPE, **kw)
        input = p.stdout
    return p.communicate()[0].strip()

def partition(els, needle):
    """
    Partitions a list by the given needle. 
    E.g. partition([1,2,3,4,5,3,6], 3) returns [[1,2], [4,5], [6]]
    """
    results = [[]]
    for el in els:
        if el == needle:
            results.append([])
        else:
            results[len(results)-1].append(el)
    return results

def dirname(file):
    """
    Returns the directory of a file, resolves realpath first.
    """
    return os.path.dirname(os.path.realpath(file))

def curry(func, *args, **kw):
    """
    Transforms a function that takes multiple arguments so that it 
    can be called with predefined argument values. 
    """
    return lambda *p, **n: func(*args + p, **dict(kw.items() + n.items()))

def without_callable(vals):
    """
    Remove elements that are callable.
    """
    return dict((k,v) for k,v in vals.items() if not hasattr(v,'__call__'))

def package_config_funcs(_result): 
    """
    Define functions that can be called in a configuration file.
    Each function appends its function parameter values to the result dictionary.
    """
    # initial configurations for package, files, confs, and globs
    _result.update(dict(REQUIRES=[], CONFLICTS=[],
                        SETTINGS=[],
                        FILES=[], CONFS=[], GLOBS=[], MKDIRS=[], FINDS=[],
                        STARTS=[], STOPS=[]))
    def _append_config(config, params):
        _result[config].append(without_callable(params))

    # -- available functions in configuration file --
    def requires(name, min=None, max=None):
        _append_config('REQUIRES', locals())

    def conflicts(name, min=None, max=None):
        _append_config('CONFLICTS', locals())

    def setting(name, *args, **kw):
        _append_config('SETTINGS', locals())

    def file(dest, src, perm=None, owner=None, group=None):
        _append_config('FILES', locals())

    def conf(dest, src, perm=None, owner=None, group=None, expand=False, overwrite=False):
        _append_config('CONFS', locals())
    
    def glob(dest, src, perm=None, owner=None, group=None):
        _append_config('GLOBS', locals())

    def mkdir(dest, perm=None, owner=None, group=None):
        dir = True
        _append_config('MKDIRS', locals())

    def find(dest, args, perm=None, owner=None, group=None):
        _append_config('FINDS', locals())

    def start(cmd, priority=100):
        _append_config('STARTS', locals())
        
    def stop(cmd, priority=100):
        _append_config('STOPS', locals())
   
    # all functions defined above without starting with '_'
    return dict((k,v) for k,v in locals().items() if k[0] != '_' and hasattr(v,'__call__'))

# --- python based config parser ---

# This parses a configuration file using a restricted Python syntax.
# The Python tokenizer/parser is used to read the file, unwanted expressions
# are removed from the parser output before the result is compiled and
# executed. The initialised configuration settings are returned in a dict.
# 
# Based on http://code.activestate.com/recipes/576404-python-config-file-parser/
# by Frithiof andreas, MIT license.

def _get_forbidden_symbols():
    import symbol
    """
    Returns a list of symbol codes representing statements that are NOT
    wanted in configuration files.
    """
    try:
        # Python 2.5:
        symlst = [symbol.break_stmt, symbol.classdef, symbol.continue_stmt,
                  symbol.decorator, symbol.decorators, symbol.eval_input,
                  symbol.except_clause, symbol.exec_stmt, symbol.flow_stmt,
                  symbol.fpdef, symbol.fplist, symbol.funcdef,
                  symbol.global_stmt, symbol.import_as_name, symbol.import_as_names,
                  symbol.import_from, symbol.import_name, symbol.import_stmt,
                  symbol.lambdef, symbol.old_lambdef, symbol.print_stmt,
                  symbol.raise_stmt, symbol.try_stmt, symbol.while_stmt,
                  symbol.with_stmt, symbol.with_var, symbol.yield_stmt,
                  symbol.yield_expr]
        
    except AttributeError:
        # Python 2.4:        
        symlst = [symbol.break_stmt, symbol.classdef, symbol.continue_stmt,
                  symbol.decorator, symbol.decorators, symbol.eval_input,
                  symbol.except_clause, symbol.exec_stmt, symbol.flow_stmt,
                  symbol.fpdef, symbol.fplist, symbol.funcdef,
                  symbol.global_stmt, symbol.import_as_name, symbol.import_as_names,
                  symbol.import_from, symbol.import_name, symbol.import_stmt,
                  symbol.lambdef, symbol.print_stmt, symbol.raise_stmt,
                  symbol.try_stmt, symbol.while_stmt]
    
    return symlst

def _parseconf(confstr):
    import parser
    """
    Parse the configuration *confstr* string and remove anything else
    than the supported constructs, which are:

    Assignments, bool, dict list, string, float, bool, and, or, xor,
    arithmetics, string expressions and if..then..else.

    The *entire* statement containing the unsupported statement is removed
    from the parser; the effect is that the whole expression is ignored
    from the 'root' down.  

    The modified AST object is returned to the Python parser for evaluation. 
    """
    # Initialise the parse tree, convert to list format and get a list of
    # the symbol ID's for the unwanted statements. Might raise SyntaxError.
    
    stmts = parser.ast2list(parser.suite(confstr))
    rmsym = _get_forbidden_symbols()

    result = list()
    
    # copy 256: 'single_input', 257: 'file_input' or 258: 'eval_input'. The
    # parse tree must begin with one of these to compile back to an AST obj.

    result.append(stmts[0])

    # NOTE: This might be improved with reduce(...) builtin!? How can we get
    #       line number for better warnings?
    for i in range(1, len(stmts)):
        # censor the parse tree produced from parsing the configuration.
        if _check_ast(stmts[i], rmsym):
            result.append(stmts[i])
        else:
            pass
    return parser.sequence2ast(result)

def _check_ast(astlist, forbidden):
    import token, symbol
    """
    Recursively check a branch of the AST tree (in list form) for "forbidden"
    symbols. A token terminates the search.

    Returns False if any "forbidden symbols" are present, True otherwise. 
    """
    # check for token - tokens are always allowed.
    if astlist[0] in token.tok_name.keys():
        return True
    
    elif astlist[0] in forbidden:
        raise UserWarning('Statement containing '+symbol.sym_name[astlist[0]]
            +' not allowed, ignored!')
        return False
    else:
        return _check_ast(astlist[1], forbidden)
       
def parse_config(confstr, shell=shell, options={}):
    import parser
    """
    Use eval(...) to execute a filtered AST tree formed by parsing a
    configuration file, removing unwanted expressions (if any) and then
    compiling the filtered output to Python byte code. This approach
    allows the use of Python expressions and comments in config files.

    The following expressions (and combinations of) are allowed:

    Assignments, Arithmetic, Strings, Lists, if...then...else and
    Comments.

    Returns a dict containing the configuration values, if successful. 
    """
  
    # add package callable functions, shell and environment variables
    # to the config eval globals.
    result = dict()
    globals = package_config_funcs(result)
    globals['shell'] = shell
    globals.update(("PLZ_%s" % k.upper(),v) for k,v in options.items())

    # Parse the python source code into a filtered AST-tree.
    # Compile AST to bytecode, this also detects syntax errors.
    cobj = parser.compileast(_parseconf(confstr))

    # Run the bytecode. The dicts global and results are placeholders
    # for the globals and locals environments used by eval(...).    
    eval(cobj, globals, result)
    return result

if __name__ == "__main__":
    if len(sys.argv) > 1:
        dispatch(sys.argv)
    else:
        print default_help()
